#coding=utf-8
"""
LoNote Book & Bookshelf 書籍與書櫃模組

伺服器對資料的操控全都要透過此層；而 IM 往上看時，也只需對此層負責。

===================================

類別說明：

    Book      = 可以使用各種不同的底層資料－－只要他們具有 IM 的 API。
                由 Bookshelf 協助初始化
    Bookshelf = 顧名思義是用來收藏一堆書的地方！
"""

import glob
import os
import os.path
import xml.etree.ElementTree as ET
import sys
import inspect
import collections
import urllib.parse as UP

import whoosh.index as WI
import whoosh.analysis as WA
import whoosh.fields as WF
import whoosh.qparser as WQ

import lonote.im as im
import civa.parse
import civa.putil

class BookError(Exception): pass # 基本錯誤
class BookshelfError(Exception): pass # 基本錯誤
class MtimeNotMatch(BookError): pass # mtime 與 pubdate 對不上時拋出

class Book:
    '''伺服器對書的操作介面'''

    support_class = [im.IM]
    # 關於搜尋的設定
    INDEX_DIR = 'bookindex'

    # 將所有字母分成個別的詞
    # analyzer = whoosh.analysis.RegexTokenizer(r'\b|\B', gaps = True)

    # 將中文字依單一字分詞，其他英文字依照一般 word 分詞
    analyzer = WA.RegexTokenizer( '([\u2E80-\u9FFF])|(\w+(\.?\w+)*)')\
            | WA.StopFilter( minsize = 0)\
            | WA.LowercaseFilter()

    # 將所有字先標準分詞，再分成 Ngram，需時極長不實用
    # analyzer = whoosh.analysis.StandardAnalyzer() | whoosh.analysis.NgramFilter(minsize=2, maxsize=4)

    # 將中英文分開，中文字用 Ngram 分詞，英文採用標準（空白）分詞
    # 有奇怪的 bug ：在 StandardAnalysis Token >= 3 時，
    # 從第二個 Token 字句開始都會變成最後一個
    # analyzer = StandardAnalyzer() | TeeFilter(
    #         SubstitutionFilter( '^[\u2E80-\u9FFF]+', ''),
    #         SubstitutionFilter( '^[^\u2E80-\u9FFF]+', '') | NgramFilter( minsize = 2, maxsize = 4)
    #         ) | StopFilter()

    schema = WF.Schema(
            buuid = WF.ID(stored = True),
            uuid = WF.ID(unique = True, stored = True),
            title = WF.TEXT(analyzer = analyzer, stored = True),
            content = WF.TEXT(analyzer = analyzer, stored = True),
            mtime = WF.DATETIME(stored = True),
            ctime = WF.DATETIME(stored = True)
            )

    def __init__(self, fpath):
        '''載入指定將的書籍資料'''
        try:
            cls = next(cls for cls in Book.support_class
                    if cls.is_this_type(fpath))
        except StopIteration:
            info = 'file type not match'
            raise BookError(info)
        cls_obj = cls(fpath)
        self.__im = cls_obj

        self.__index_init()

    def close(self):
        self.__im.close()

    def __index_init(self):
        '''關於搜尋的初始化'''
        buuid = self.book_uuid()
        if WI.exists_in(Book.INDEX_DIR, indexname = buuid):
            self.__ix = WI.open_dir(
                    Book.INDEX_DIR, indexname = buuid)
        else:
            self.index_recreate()

    def index_recreate(self):
        '''重建 index'''
        buuid = self.book_uuid()
        self.__ix = WI.create_in(dirname = Book.INDEX_DIR,
                schema = Book.schema,
                indexname = buuid)
        # 遍歷內部所有資料
        ebook = ET.fromstring(self.book_xml())
        writer = self.__ix.writer()
        for enote in ebook.iter('note'):
            uuid = enote.get('uuid')
            content = self.content(uuid)
            txt = civa.parse.html2txt(content) # 純文字化
            writer.update_document(
                  buuid = buuid,
                  uuid = uuid,
                  title = self.title(uuid),
                  content = txt,
                  mtime = self.mtime(uuid),
                  ctime = self.ctime(uuid))
        writer.commit()

    def __index_update(self, uuid):
        '''更新指定 uuid 的 index'''
        buuid = self.book_uuid()
        content = self.content(uuid)
        txt = civa.parse.html2txt(content) # 純文字化
        if uuid is None:
            uuid = buuid
        writer = self.__ix.writer()
        writer.update_document(
              buuid = buuid,
              uuid = uuid,
              title = self.title(uuid),
              content = txt,
              mtime = self.mtime(uuid),
              ctime = self.ctime(uuid))
        writer.commit()

    def __index_delete(self, uuid):
        '''刪掉指定 uuid 的索引'''
        writer = self.__ix.writer()
        writer.delete_by_term('uuid', uuid)
        writer.commit()

    # 純讀取功能

    def book_uuid(self):
        '''筆記簿 uuid'''
        return self.__im.book_uuid()

    def book_xml(self):
        '''筆記的xml'''
        return self.__im.book_xml()

    def history_xml(self, uuid):
        '''某筆記頁的 history xml'''
        return self.__im.history_xml(uuid = uuid)

    def record_list(self, uuid):
        return self.__im.record_list(uuid = uuid)

    def resource_xml(self, uuid):
        '''某筆記頁的 resource xml'''
        return self.__im.resource_xml(uuid = uuid)

    def resource_list(self, uuid):
        return self.__im.resource_list(uuid = uuid)

    @property
    def fpath(self):
        '''回傳當初初始化用的fpath'''
        return self.__im.fpath

    @property
    def time_format(self):
        '''回傳xml所用的標準時間格式'''
        return self.__im.time_format

    def mtime(self, uuid):
        '''回傳筆記修改時間'''
        return self.__im.mtime(uuid)

    def ctime(self, uuid):
        '''回傳筆記建立時間'''
        return self.__im.ctime(uuid)

    def ftime(self, uuid, name):
        '''回傳歷史記錄凍結時間'''
        return self.__im.ftime(uuid, name)

    def position(self, uuid):
        '''回傳當前note位置，為(parent_uuid, index)'''
        return self.__im.position(uuid)

    def uuid_before(self, uuid):
        return self.__im.uuid_before(uuid)

    def uuid_next(self, uuid):
        return self.__im.uuid_next(uuid)

    def uuid_first(self, uuid):
        return self.__im.uuid_first(uuid)

    def uuid_last(self, uuid):
        return self.__im.uuid_last(uuid)

    # 讀取兼寫入功能

    def title(self, uuid = None, title = None):
        '''筆記名稱'''
        if title is None:
            return self.__im.title(uuid = uuid)
        else:
            ans = self.__im.title_update(uuid = uuid, title = title)
            self.__index_update(uuid)
            return ans

    def content(self, uuid, content = None, basetime = None):
        '''筆記內容

            content  = 要存入的文字，有設值表示寫入
            basetime = datatime 格式的物件，表示要存入的文字，是基於哪個
                       時間版本的內容而撰寫的。
                       如果有提供，則要求 basetime 等於指定筆記的 mtime，
                       以避免多人共同編輯時，互相覆寫彼此的修改版本。
                       沒有提供則無視。
            '''
        # def ftimes(uuid):
        #     xml = self.__im.history_xml(uuid = uuid)
        #     ehistory = ET.fromstring(xml)
        #     ftimes = [DT.datetime.strftime(e.text, self.time_format) for e
        #             in ehistory.find('./record/frozen_time')]
        #     return ftimes

        if content is None:
            return self.__im.content(uuid = uuid)
        else:
            if basetime and basetime != self.__im.mtime(uuid):
                info = 'Content update after load!'
                raise MtimeNotMatch(info)
            self.__im.content_update(uuid = uuid, content = content)
            self.__index_update(uuid)
            mtime = self.__im.mtime(uuid)
            return mtime

    def resource(self, uuid, name, res_type = None, data = None):
        '''附屬資源。如需更新附屬資源，需同時指定最後兩個參數'''
        if data is None:
            return self.__im.resource(uuid = uuid, name = name)
        else:
            return self.__im.resource_update(
                    uuid = uuid,
                    name = name,
                    res_type = res_type,
                    data = data)

    def record(self, uuid, *, name = None, comment = None, content = None):
        '''筆記記錄歷史資料

            name 設值表示讀取
            comment 設值表示寫入

            （以上兩者必需擇一。content只在寫入時起作用，也可以不給）

            如果設值操作回傳 name，若是讀取操作回傳內容
            '''
        if name is not None:
            return self.__im.record(uuid = uuid, name = name)
        elif comment is not None:
            name = self.__im.record_create(uuid = uuid,
                    comment = comment,
                    content = content)
            return name
        else:
            info = 'One of "name" & "comment" must be set!'
            raise BookError(info)

    def extra(self, key, obj = None):
        '''更新或讀取指定的extra

            當讀取時指定物件不存在，則回傳 None
            '''
        if obj is None:
            return self.__im.extra(key)
        else:
            return self.__im.extra_update(key, obj)

    # 更新與新建功能

    def position_update(self, uuid, parent_uuid, order):
        '''變更uuid的所在位置'''
        return self.__im.position_update(
                uuid = uuid, parent_uuid = parent_uuid, order = order)

    def note_create(self, title, parent_uuid, order = None):
        '''建立一頁新筆記'''
        return self.__im.note_create(
                title = title, parent_uuid = parent_uuid, order = order)

    # 刪除功能

    def note_delete(self, uuid, use_trashcan = True):
        '''刪除一個筆記'''
        if use_trashcan:
            ans = self.__im.note_delete(uuid = uuid)
        else:
            ans = self.__im.note_delete(uuid = uuid, trashcan_uuid = None)
        self.__index_delete(uuid)
        return ans

    def record_delete(self, uuid, name):
        '''刪除一筆歷史記錄'''
        return self.__im.record_delete(uuid = uuid, name = name)

    def resource_delete(self, uuid, name):
        '''刪除一份附加資源'''
        return self.__im.resource_delete(uuid = uuid, name = name)

    def clear_unused_embedded(self, uuid):
        '''刪除沒有出現於指定 uuid 正文中的附屬embedded'''
        # 取得所有 emb 類資源檔案名稱
        resource_list = self.__im.resource_list(uuid = uuid)
        emb_names = [i.get('name') for i
                in resource_list if i.get('res_type') == 'emb']
        # 取得所有筆記內容資料（含 record）
        record_list = self.__im.record_list(uuid = uuid)
        record_names = [i.get('name') for i in record_list]
        contents = [self.__im.record(uuid = uuid, name = name).lower()
                for name in record_names]
        contents.append(self.__im.content(uuid = uuid).lower())
        # 因為資料可能透過 % 編碼被存取，所以將所有 content 做相應 unescape
        contents = [ UP.unquote(c) for c in contents]
        # 資料獲取完成，開始分析哪些要移除
        need_remove = emb_names
        for content in contents:
            need_remove = {name for name
                    in need_remove if (name.lower() not in content)}
        # 刪除
        for name in need_remove:
            self.__im.resource_delete(uuid = uuid, name = name)

    def extra_delete(self, key):
        '''刪掉extra'''
        return self.__im.extra_delete(key)

    # 補助功能

    def flush(self, mode = None):
        '''壓制書籍'''
        if mode is None:
            return self.__im.flush()
        else:
            return self.__im.flush(mode)

    def book_export(self, uuid, new_fpath):
        '''匯出'''
        return self.__im.im_export(uuid, new_fpath)

    def book_import(self, parent_uuid, in_fpath):
        '''匯入'''
        return self.__im.im_import(parent_uuid, in_fpath)

    # 查詢功能

    def search(self, key):
        '''輸入關鍵字串來搜索這本筆記'''
        ans = []
        with self.__ix.searcher() as searcher:
            # 產生查詢字串
            query = WQ.MultifieldParser(['title', 'content'], self.__ix.schema).parse(key)
            # 搜尋！
            results = searcher.search(query, limit = 10)
            for hit in results:
                path = '/site/{}/{}/'.format(hit['buuid'], hit['uuid'])
                try:
                    puuid, index = self.position(hit['uuid'])
                    ptitle = self.title(puuid)
                except im.IMNotExist:
                    puuid = ''
                    ptitle = '✖'
                ans.append({'title': hit['title'],
                    'uuid': hit['title'],
                    'buuid': hit['buuid'],
                    'path': path,
                    'puuid': puuid,
                    'ptitle': ptitle,
                    'btitle': self.title(),
                    'score': hit.score,
                    'fragment': hit.highlights("content")
                    })
            return ans

class Bookshelf():
    loadfile = 'needload.xml'

    def __init__(self, books_dir):
        self.books = collections.OrderedDict()
        self.books_dir = books_dir
        self.lf = os.path.join(self.books_dir, Bookshelf.loadfile)

        self.loadbooks()

    def close(self):
        for book in self.books.values():
            book.close()
        self.books = collections.OrderedDict()

    def load_status(self):
        '''回傳一個記錄載入狀況的字典

            return ==> [(fname, loaded?, buuid), ...]

            fname   = 不含路徑的檔名。
            loaded? = 目前是否有載入。
            buuid   = 如果有載入，提供buuid號碼。沒載入為 None
            '''
        match = os.path.join(self.books_dir, '*.lobook')
        indir = {os.path.basename(fpath) for fpath in glob.glob(match)}

        fname2buuid = {os.path.basename(book.fpath): buuid
                for buuid, book in self.books.items()}

        l_status = [] # 最終結果

        elf = self.elf()
        for efile in elf: # 生成已列入管理部份的資料
            fname = efile.get('fname')
            if fname in indir:
                one_status = (
                        fname,
                        bool(int(efile.get('should_load'))),
                        fname2buuid.get(fname))

                indir.remove(fname)
                l_status.append(one_status)
        for fname in indir: # 生成先前未列入管理的資料
            one_status = ( fname, False, None)
            l_status.append(one_status)

        return l_status

    def elf(self):
        '''取得e_loadfile'''
        if os.path.isfile(self.lf):
            return ET.parse(self.lf).getroot()
        else: # 建立
            self.lf_init()
            return self.elf()

    def lf_init(self, loadlist = [], default_all = True):
        '''初始化 lf，如果有提供loadlist則使用loadlist初始化

            loadlist 每個元素均為 (fname, should_load)

            loadlist 不存在時：
            default_all = True 表示全部讀入
                          False 表示全部不讀入
            '''
        # 沒有指定 fnames 時，直接將所有能載入的檔案全部載入
        if not loadlist:
            if default_all:
                match = os.path.join(self.books_dir, '*.lobook')
                loadlist = [(fpath, True) for fpath in glob.glob(match)]
            else: pass

        # 產生完整的needload
        root = ET.Element('needload')
        for fpath, should_load in loadlist:
            fname = os.path.basename(fpath)
            ET.SubElement(root, 'file' , fname = fname, should_load = str(int(should_load)))
        self.__elf_update(root)

    def __elf_update(self, root):
        '''把更新好的 elf 寫回檔案'''
        xml = ET.tostring(root, encoding = "unicode")
        with open(self.lf, mode = 'w', encoding = 'utf-8') as f:
            f.write(xml)

    def loadbooks(self):
        '''載入self.books_dir下所有的筆記本'''
        self.close() # 先卸載原本的資料……
        elf = self.elf()
        for efile in elf.findall('./file'):
            should_load = bool(int(efile.get('should_load')))
            if should_load:
                fname = efile.get('fname')
                try:
                    self.addbook(fname)
                # 可能因為某些原因（如重複、格式不正確等）而載入失敗
                except BookshelfError: pass

    # def unloadbook(self, fname):
    #     '''卸載 fname 的書

    #         fname 可為筆記檔案名或 bookuuid 名
    #     '''
    #     book = self.books.get(fname)
    #     buuid = None
    #     if book:
    #         fname = book.fpath
    #         buuid = book.book_uuid()
    #     else:
    #         for buuid in self.books:
    #             book = self.books[buuid]
    #             if book.fpath == fname:
    #                 buuid = book.book_uuid()
    #                 break
    #     # 確實找到了，踢掉
    #     if buuid and fname:
    #         elf = self.elf()
    #         efile = elf.find('./file[@fname="{}"]'.format(fname))
    #         elf.remove(efile)
    #         self.__elf_update(elf)
    #         self.books.pop(buuid) # 從當前筆記簿資料庫中踢掉

    def create_book(self, fname):
        '''初始化一本新書。fname不可被佔用，否則拋出錯誤。'''
        # 計算路徑
        fpath = os.path.join(self.books_dir, fname)
        if os.path.isfile(fpath):
            info = '"{}" be occupied'.format(fpath)
            raise BookshelfError(info)
        else:
            return self.addbook(fname, create = True)

    def addbook(self, fname, file_obj = None, create = False):
        '''掛載名為 fname 的書，所有掛載書籍的動作都是透過這邊進行

            fname     = 為筆記檔案名
            file_obj  = 為一個可 read() 的檔案物件，
                        若為 None 表示讀取現有的檔案或新建檔案，
                        fpath　存在不存在都可以；
                        若非 None，fpath 不可被佔用，否則拋出錯誤
            create    = True 表示當前動作為「新建」，fname 不可被佔用
                        False 表當前動作為「讀取」，fname 必需被佔用
                        只在 file_obj 不存在時生效

        '''
        def remove_unused_elf(fname):
            '''在elf中刪掉指定fname的項目'''
            elf = self.elf()
            efile = elf.find('./file[@fname="{}"]'.format(fname))
            if efile is not None:
                elf.remove(efile)
                self.__elf_update(elf)

        # 計算路徑
        fpath = os.path.join(self.books_dir, fname)
        already_exist = os.path.isfile(fpath)
        # 將檔案寫入
        if file_obj:
            if already_exist:
                info = '"{}" be occupied'.format(fpath)
                raise BookshelfError(info)
            else: # 寫入
                with open(fpath, mode = "wb") as f:
                    f.write(file_obj.read())
                try: # 寫入後讀取看看，看能不能正常使用
                    b = Book(fpath)
                except BookError: # 不能用則刪除
                    os.remove(fpath)
                    # 清除無用的 elf
                    remove_unused_elf(fname)
                    info = 'file not accept!'
                    raise BookshelfError(info)
        # 除了新寫入以外的狀況，檔案都該事先存在
        else:
            if create and already_exist:
                info = '"{}" be occupied'.format(fpath)
                raise BookshelfError(info)
            elif not create and not already_exist:
                info = 'file not found!'
                remove_unused_elf(fname)
                raise BookshelfError(info)
            # 通過檢查，嚐試建立或讀取
            try:
                b = Book(fpath)
            except:
                info = 'load / create Failed!'
                raise BookshelfError(info)

        # 如果原本就存在一筆有著相同 uuid 的資料，則後讀入的項目放棄載入
        if b.book_uuid() not in self.books:
            # 加入
            self.books[b.book_uuid()] = b
            elf = self.elf()
            efile = elf.find('./file[@fname="{}"]'.format(fname))
            if efile is None:
                ET.SubElement(elf, 'file', fname = fname, should_load = '1')
            else:
                efile.set('should_load', '1')
            self.__elf_update(elf)
            return b
        else:
            # 此時要將它從elf中移除
            b.close()
            elf = self.elf()
            efile = elf.find('./file[@fname="{}"]'.format(fname))
            if efile is not None:
                efile.set('should_load', '0')
                self.__elf_update(elf)
            info = 'Duplicated bookuuid'.format(fpath)
            raise BookshelfError(info)

    def removebook(self, fname):
        '''刪除一本書'''
        fname = os.path.basename(fname)
        fname2buuid = {os.path.basename(book.fpath): buuid
                for buuid, book in self.books.items()}
        buuid = fname2buuid.get(fname)
        fpath = os.path.join(self.books_dir, fname)
        if buuid:
            book = self.findbook(buuid) # 找到
            # 先壓成一個檔案
            book.flush()
            # 從書架中踢掉
            self.books.pop(buuid)
            elf = self.elf()
            efile = elf.find('./file[@fname="{}"]'.format(fname))
            if efile is not None:
                elf.remove(efile)
                self.__elf_update(elf)

            book.close()
            # 只留下bak檔案
            bak_fpath = civa.putil.chext(fpath, 'bak')
            if os.path.isfile(bak_fpath):
                os.remove(bak_fpath)
            os.rename(fpath, bak_fpath)
        else:
            # 當書本未被讀入，嘗試讀取再移除
            try:
                self.addbook(fname)
            except: pass
            self.removebook(fname)


    def findbook(self, bookuuid):
        '''透過bookuuid尋找book'''
        book = self.books.get(bookuuid)
        if book is not None:
            return book
        else:
            raise BookshelfError('book not found!')

    def ebooks(self):
        '''回傳 ebooks'''
        return [ET.fromstring(b.book_xml()) for b in self.books.values()]

    def ehistory(self, bookuuid, uuid):
        '''回傳 ehistory'''
        xml = self.findbook(bookuuid).history_xml(uuid)
        return ET.fromstring(xml)

    def search(self, key):
        '''輸入關鍵字串，進行搜尋'''
        ans = []
        for book in self.books.values():
            ans.extend(book.search(key))

        ans = sorted(ans, key = lambda x: x['score'], reverse = True)
        return ans

    # def erecord(self, bookuuid, uuid, name):
    #     '''回傳 erecord'''
    #     ehist = self.ehistory(bookuuid, uuid)
    #     e = ehist.find('./record[@name="{}"]'.format(name))
    #     if e is None:
    #         info = 'No record name = "{}".'.format(name)
    #         raise BookshelfError('book not found!')
    #     return e
